DTO 검증과 도메인 검증은 무엇이 다른가

DTO 검증과 도메인 검증은 무엇이 다른가

한눈에 보기

DTO 검증은 요청의 모양과 기본 범위가 올바른지 확인한다. 도메인 검증은 현재 상태와 정책을 바탕으로 이 작업을 지금 허용할 수 있는지 판단한다. 둘 중 하나로 다른 하나를 대신할 수 없으며, 동시성에 의해 깨질 수 있는 불변식은 데이터베이스 제약이나 조건부 쓰기로 마지막까지 보장해야 한다.

목차

같은 검증이라는 말이 서로 다른 질문을 가린다

쿠폰 발급 API가 다음 요청을 받는다고 해 보자.

{
  "campaignId": "cmp_spring_2025",
  "userId": "usr_1042",
  "discountRate": 15
}

이 요청에는 서로 성격이 다른 질문이 섞여 있다.

  1. campaignIduserId는 문자열인가?
  2. discountRate는 정수이며 1에서 50 사이인가?
  3. 해당 캠페인이 실제로 존재하는가?
  4. 캠페인의 발급 기간이 아직 끝나지 않았는가?
  5. 사용자가 이미 이 캠페인의 쿠폰을 받지 않았는가?
  6. 남은 발급 수량이 있는가?
  7. 이 사용자가 캠페인의 대상 조건을 충족하는가?

앞의 두 질문은 요청 하나만 보면 답할 수 있다. 반면 뒤의 질문은 데이터베이스의 현재 상태나 업무 정책을 읽어야 답할 수 있다. 모두 “검증”이라고 부르지만, 필요한 정보와 실행 위치, 실패의 의미가 다르다.

구분 답하려는 질문 필요한 정보 대표 실패
DTO/스키마 검증 입력의 모양이 계약에 맞는가 현재 요청 잘못된 타입, 누락, 범위 초과
도메인 검증 업무 규칙상 이 명령을 허용할 수 있는가 요청 + 현재 상태 + 정책 기간 종료, 자격 미달, 잘못된 상태 전이
저장소 제약 경쟁 요청이 있어도 불변식이 유지되는가 커밋 시점의 DB 상태 UNIQUE, CHECK, FK, 조건부 UPDATE 실패
핵심 구분

DTO 검증이 통과했다는 말은 처리할 수 있는 형태의 명령을 받았다는 뜻이지, 그 명령을 성공시켜도 된다는 뜻이 아니다.

검증을 세 개의 경계로 나누어 보기

하나의 요청은 대략 다음 경계를 지난다.

flowchart LR
    A[외부 입력] --> B[DTO / Schema]
    B -->|형식 오류| X[400 Bad Request]
    B --> C[Application / Domain]
    C -->|업무 규칙 위반| Y[409 또는 422]
    C --> D[(Database)]
    D -->|제약 충돌| Z[도메인 오류로 변환]
    D --> E[커밋]

각 경계가 맡을 책임을 명확히 하면 코드가 단순해진다.

이 세 층은 중복이 아니라 방어선이다. 예를 들어 할인율 1..50 규칙을 DTO와 DB CHECK에 모두 둘 수 있다. DTO는 사용자에게 빠르고 이해하기 쉬운 오류를 주며, DB 제약은 배치나 관리자 도구가 애플리케이션 검증을 우회하더라도 잘못된 값이 저장되지 않게 한다.

DTO 검증이 잘하는 일

NestJS의 ValidationPipeclass-validator를 사용한다면 요청 형식을 다음처럼 표현할 수 있다.

import { Type } from "class-transformer";
import {
  IsInt,
  IsString,
  Length,
  Max,
  Min,
} from "class-validator";

export class IssueCouponDto {
  @IsString()
  @Length(8, 40)
  campaignId!: string;

  @IsString()
  @Length(8, 40)
  userId!: string;

  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(50)
  discountRate!: number;
}

전역 파이프는 예상하지 못한 속성을 거부하고 변환을 명시적으로 수행하도록 설정할 수 있다.

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    whitelist: true,
    forbidNonWhitelisted: true,
  }),
);

이 계층에서 다루기 좋은 규칙은 입력만으로 결정적이어야 한다.

DTO를 클래스로 선언할 때는 런타임 메타데이터가 필요하다는 점도 주의해야 한다. interface는 컴파일 후 사라지고, DTO를 import type으로만 가져오면 런타임 검증이 기대대로 동작하지 않을 수 있다.

변환은 검증과 동일하지 않다

transform: true가 문자열 "12"를 숫자 12로 바꿔 주더라도, 어떤 변환을 허용할지는 API 계약의 일부다. 빈 문자열이나 NaN처럼 애매한 값이 조용히 통과하지 않는지 경계값 테스트가 필요하다.

교차 필드 검증은 어디까지 DTO가 맡을까

두 필드만으로 결정되는 규칙은 DTO 스키마에서 처리해도 된다. 예를 들어 예약 검색 요청에서 fromto보다 늦으면 외부 상태를 읽지 않고도 잘못된 요청임을 알 수 있다.

type SearchPeriod = {
  from: Date;
  to: Date;
};

function assertPeriodShape(period: SearchPeriod): void {
  if (period.from >= period.to) {
    throw new InvalidRequestError("from은 to보다 빨라야 합니다.");
  }
}

다만 “예약 가능한 기간인가”는 영업일, 이미 잡힌 예약, 상품별 정책을 알아야 하므로 도메인 규칙이다. 필드가 둘 이상이라는 사실보다 외부 상태나 변경 가능한 정책이 필요한가가 더 중요한 구분 기준이다.

DTO에 도메인 규칙을 넣으면 생기는 문제

class-validator는 비동기 사용자 정의 validator를 지원하므로, 데이터베이스 조회를 데코레이터 안에 넣고 싶은 유혹이 생긴다.

@ValidatorConstraint({ async: true })
class IsCampaignOpenConstraint implements ValidatorConstraintInterface {
  constructor(private readonly campaignRepository: CampaignRepository) {}

  async validate(campaignId: string): Promise<boolean> {
    const campaign = await this.campaignRepository.findById(campaignId);
    return campaign !== null && campaign.closesAt > new Date();
  }
}

보기에는 선언적이지만 몇 가지 비용이 숨어 있다.

첫째, DTO 하나를 검증할 때 DB 쿼리가 발생한다. 어떤 속성 검증이 I/O를 일으키는지 호출부에서 보이지 않고, 배열 요청에서는 검증기 수만큼 쿼리가 늘어날 수도 있다.

둘째, 검증 시점과 실제 쓰기 시점 사이에 상태가 바뀔 수 있다. validator에서 수량이 남았다고 확인한 직후 다른 요청이 마지막 수량을 가져갈 수 있다. 검증기를 비동기로 만들었다고 동시성 문제가 해결되지는 않는다.

셋째, HTTP DTO에 규칙이 붙으면 메시지 소비자, 배치, CLI 같은 다른 진입점이 같은 규칙을 재사용하기 어렵다. 도메인 규칙이 전송 계층의 데코레이터에 갇힌다.

넷째, 실패 의미가 평평해진다. 캠페인이 존재하지 않는 경우, 종료된 경우, 사용 자격이 없는 경우가 모두 단순한 “필드 검증 실패”가 되기 쉽다. 클라이언트가 취해야 할 행동은 서로 다른데 오류 표현이 이를 잃는다.

실용적인 판단 질문

“이 규칙을 검사하려고 저장소나 현재 시각, 외부 정책 서비스가 필요한가?”라는 질문에 라면 DTO보다 유스케이스나 도메인 계층에 두는 편이 자연스럽다.

도메인 검증은 상태와 정책을 다룬다

도메인 검증은 입력을 이미 신뢰할 수 있는 형태의 명령으로 받은 뒤 시작한다.

type IssueCouponCommand = Readonly<{
  campaignId: string;
  userId: string;
  requestedRate: number;
}>;

class CouponIssuanceService {
  constructor(
    private readonly campaigns: CampaignRepository,
    private readonly coupons: CouponRepository,
    private readonly clock: Clock,
  ) {}

  async issue(command: IssueCouponCommand): Promise<IssuedCoupon> {
    const campaign = await this.campaigns.findById(command.campaignId);

    if (!campaign) {
      throw new CampaignNotFoundError(command.campaignId);
    }

    campaign.assertIssuableAt(this.clock.now());
    campaign.assertRateAllowed(command.requestedRate);

    const alreadyIssued = await this.coupons.existsByCampaignAndUser(
      command.campaignId,
      command.userId,
    );

    if (alreadyIssued) {
      throw new CouponAlreadyIssuedError();
    }

    return this.coupons.issue(command);
  }
}

시간을 new Date()로 바로 읽지 않고 Clock으로 주입하면 경계 시각을 결정적으로 테스트할 수 있다.

interface Clock {
  now(): Date;
}

class FixedClock implements Clock {
  constructor(private readonly value: Date) {}

  now(): Date {
    return new Date(this.value);
  }
}

캠페인 자체가 자신의 상태 전이를 책임지게 할 수도 있다.

class Campaign {
  constructor(
    private readonly opensAt: Date,
    private readonly closesAt: Date,
    private readonly maxRate: number,
  ) {}

  assertIssuableAt(now: Date): void {
    if (now < this.opensAt) {
      throw new CampaignNotStartedError();
    }

    if (now >= this.closesAt) {
      throw new CampaignClosedError();
    }
  }

  assertRateAllowed(rate: number): void {
    if (rate > this.maxRate) {
      throw new DiscountPolicyViolationError(this.maxRate);
    }
  }
}

이 방식의 장점은 규칙이 HTTP와 무관하다는 점이다. REST 컨트롤러, 큐 소비자, 관리자 배치가 모두 같은 애플리케이션 서비스를 호출할 수 있다.

정책과 불변식을 구분한다

모든 규칙이 같은 강도를 갖는 것도 아니다.

변경 가능한 정책은 별도 객체로 추출하면 조건문이 서비스 전체에 퍼지는 것을 막을 수 있다.

interface CouponEligibilityPolicy {
  evaluate(input: {
    memberGrade: MemberGrade;
    joinedAt: Date;
    now: Date;
  }): EligibilityResult;
}

type EligibilityResult =
  | { eligible: true }
  | { eligible: false; reason: "GRADE" | "JOIN_AGE" };

조회 후 검사만으로는 부족한 이유

다음 코드는 단일 요청에서는 자연스럽게 동작한다.

const exists = await prisma.coupon.findFirst({
  where: { campaignId, userId },
});

if (exists) {
  throw new CouponAlreadyIssuedError();
}

await prisma.coupon.create({
  data: { campaignId, userId, discountRate },
});

하지만 같은 사용자의 요청 두 개가 동시에 들어오면 둘 다 exists === null을 보고 생성할 수 있다.

sequenceDiagram
    participant A as 요청 A
    participant B as 요청 B
    participant DB as Database
    A->>DB: 기존 쿠폰 조회
    B->>DB: 기존 쿠폰 조회
    DB-->>A: 없음
    DB-->>B: 없음
    A->>DB: INSERT
    B->>DB: INSERT
    Note over A,B: 애플리케이션 검사만으로 중복을 막지 못함

따라서 사용자당 하나라는 불변식은 복합 UNIQUE 제약으로 보장한다.

model Coupon {
  id           String @id @default(cuid())
  campaignId   String
  userId       String
  discountRate Int

  @@unique([campaignId, userId], name: "campaign_user_unique")
}

서비스의 사전 조회는 친절한 오류와 불필요한 INSERT 방지를 위해 남길 수 있다. 그러나 최종 방어선은 UNIQUE 제약이며, 충돌 오류도 예상 가능한 도메인 실패로 변환해야 한다.

수량 차감은 조건부 UPDATE로 원자화할 수 있다.

const result = await tx.campaign.updateMany({
  where: {
    id: campaignId,
    remainingQuantity: { gt: 0 },
    opensAt: { lte: now },
    closesAt: { gt: now },
  },
  data: {
    remainingQuantity: { decrement: 1 },
  },
});

if (result.count !== 1) {
  throw new CampaignNotIssuableError();
}

SELECT로 수량을 읽고 JavaScript에서 > 0을 검사한 뒤 UPDATE하는 것보다, 조건과 변경을 한 문장에 묶으면 경쟁 요청 사이의 틈이 줄어든다. 쿠폰 생성까지 하나의 불변식이라면 같은 트랜잭션 안에서 실행한다.

중복 검증은 의도적일 수 있다

애플리케이션의 검증은 설명 가능한 실패를 제공하고, 데이터베이스 제약은 우회 경로와 경쟁 상태를 막는다. 목적이 다르므로 같은 규칙이 두 곳에 보인다고 무조건 제거할 중복은 아니다.

오류를 경계별로 번역하기

오류 응답을 모두 400으로 만들면 클라이언트는 재시도하거나 사용자에게 안내할 근거를 잃는다. 반대로 내부 제약 이름이나 ORM 오류 코드를 그대로 노출하면 구현 세부사항이 API 계약이 된다.

예시 정책은 다음과 같이 만들 수 있다.

상황 도메인 오류 코드 HTTP 예시 클라이언트 행동
타입·형식 오류 INVALID_REQUEST 400 입력 수정
캠페인 없음 CAMPAIGN_NOT_FOUND 404 대상 새로고침
이미 발급됨 COUPON_ALREADY_ISSUED 409 기존 쿠폰 조회
기간 종료·자격 미달 CAMPAIGN_NOT_ISSUABLE 422 정책 안내
경쟁으로 수량 소진 CAMPAIGN_SOLD_OUT 409 재시도 중단

Prisma의 고유 제약 오류를 인프라 계층에서 변환하는 예시는 다음과 같다.

async function insertCoupon(
  tx: Prisma.TransactionClient,
  data: CouponCreateData,
): Promise<CouponRow> {
  try {
    return await tx.coupon.create({ data });
  } catch (error) {
    if (isUniqueConstraintViolation(error, "campaign_user_unique")) {
      throw new CouponAlreadyIssuedError({ cause: error });
    }

    throw error;
  }
}

HTTP 필터는 도메인 오류를 외부 계약으로 변환한다.

@Catch(CouponAlreadyIssuedError)
export class CouponConflictFilter implements ExceptionFilter {
  catch(error: CouponAlreadyIssuedError, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse<Response>();

    response.status(409).json({
      code: "COUPON_ALREADY_ISSUED",
      message: "이미 발급된 쿠폰입니다.",
    });
  }
}

도메인 객체가 HttpException을 직접 던지지 않게 하면 같은 규칙을 메시지 처리에서도 사용할 수 있다. 메시지 소비자는 이 오류를 재시도 불가로 분류하고 ACK할 수 있으며, HTTP 계층은 409로 표현할 수 있다.

HTTP 밖에서도 같은 규칙을 재사용하기

좋은 경계는 진입점이 달라져도 흐름이 유지된다.

@Post(":campaignId/coupons")
async issue(
  @Param("campaignId") campaignId: string,
  @Body() body: IssueCouponDto,
): Promise<IssueCouponResponse> {
  const coupon = await this.issuanceService.issue({
    campaignId,
    userId: body.userId,
    requestedRate: body.discountRate,
  });

  return { couponId: coupon.id };
}
@Process("issue-coupon")
async handle(job: Job<IssueCouponMessage>): Promise<void> {
  await this.issuanceService.issue({
    campaignId: job.data.campaignId,
    userId: job.data.userId,
    requestedRate: job.data.discountRate,
  });
}

메시지도 신뢰할 수 없는 입력이므로 소비자 입구에서 스키마 검증은 여전히 필요하다. 중요한 점은 DTO 클래스를 억지로 공유하는 것이 아니라, 각 전송 방식이 자신의 입력 계약을 검사한 후 공통 명령과 유스케이스로 변환하는 것이다.

const IssueCouponMessageSchema = z.object({
  campaignId: z.string().min(8).max(40),
  userId: z.string().min(8).max(40),
  discountRate: z.number().int().min(1).max(50),
});

REST DTO와 큐 스키마의 모양이 우연히 같더라도 수명 주기는 다를 수 있다. 외부 API는 버전 호환을 유지해야 하고, 내부 이벤트는 배포 순서에 따른 호환성이 필요하다. 도메인 명령을 중심에 두고 어댑터가 변환하게 하면 이 차이를 흡수하기 쉽다.

테스트도 검증의 종류에 따라 나눈다

검증 위치가 분리되면 테스트 목적도 선명해진다.

DTO 테스트

네트워크 입력의 경계값과 변환을 확인한다.

describe("IssueCouponDto", () => {
  it.each([0, 51, 1.5])("할인율 %s를 거절한다", async (discountRate) => {
    const errors = await validateDto(IssueCouponDto, {
      campaignId: "cmp_spring_2025",
      userId: "usr_1042",
      discountRate,
    });

    expect(errors).toContainEqual(
      expect.objectContaining({ property: "discountRate" }),
    );
  });
});

도메인 단위 테스트

고정 시계와 메모리 저장소로 정책 분기를 확인한다.

it("종료 시각과 같으면 발급을 거절한다", async () => {
  const clock = new FixedClock(new Date("2025-09-02T09:00:00Z"));
  const service = createService({
    clock,
    campaign: campaignClosingAt("2025-09-02T09:00:00Z"),
  });

  await expect(service.issue(validCommand())).rejects.toBeInstanceOf(
    CampaignClosedError,
  );
});

통합 테스트

DB 제약과 동시성 보장을 실제 저장소에서 확인한다.

it("동시에 두 번 요청해도 쿠폰은 하나만 생성된다", async () => {
  const results = await Promise.allSettled([
    service.issue(validCommand()),
    service.issue(validCommand()),
  ]);

  expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
  expect(await prisma.coupon.count()).toBe(1);
});

단위 테스트만으로는 UNIQUE 제약 이름 오타, 트랜잭션 누락, 실제 격리 수준에서의 경쟁을 잡기 어렵다. 불변식이 중요할수록 실제 데이터베이스를 사용하는 통합 테스트를 한두 개라도 둔다.

운영에서 확인할 지표

검증 실패는 단순한 오류 수가 아니라 제품과 클라이언트 상태를 보여 주는 신호다.

단, 원본 요청 본문과 이메일, 토큰을 로그에 그대로 남기지 않는다. 오류 코드, 요청 ID, 캠페인 ID처럼 조사에 필요한 최소 정보만 구조화해 기록한다.

경보 기준

DTO 실패율 급증은 클라이언트와 서버 계약 불일치일 수 있다. DB 제약 충돌 급증은 공격이 아니라 인기 캠페인의 정상 경쟁일 수도 있으므로, 도메인 코드와 트래픽을 함께 해석해야 한다.

정리

DTO 검증과 도메인 검증을 가르는 가장 쉬운 질문은 다음 두 가지다.

  1. 현재 요청만 보고 참과 거짓을 결정할 수 있는가?
  2. 경쟁 요청이 들어와도 이 검사만으로 규칙이 유지되는가?

첫 번째가 라면 DTO나 스키마 검증이 적합하다. 현재 상태, 시간, 권한, 정책이 필요하면 유스케이스와 도메인 계층에서 판단한다. 두 번째가 아니오라면 UNIQUE·CHECK·외래 키·조건부 UPDATE·트랜잭션 같은 저장소 보장이 추가로 필요하다.

입력 검증은 불신할 수 있는 데이터를 처리 가능한 명령으로 만들고, 도메인 검증은 그 명령을 지금 실행해도 되는지 결정한다. 데이터베이스는 그 결정이 경쟁 상태에서도 깨지지 않게 지킨다.

관련 노트와 참고 자료